feat: add organization-scoped user creation (POST /api/v1/users) - #23
Conversation
Implements Phase 1 of #15 — allows authenticated org admins to create users inside their own organization without going through registration. Changes: - Add UserService.Create method with password validation, bcrypt hashing, optional role assignment, audit logging, and webhook dispatch - Add CreateUser handler with full swagger annotations - Wire POST /api/v1/users route with users:write permission - Export ValidatePassword for reuse across services - Add AuditActionUserCreated constant - Add authorization tests (forbidden, granted, superuser bypass) - Add 14 service-layer unit tests covering happy paths, password policy, duplicate emails, cross-org isolation, and role validation Security: org_id from JWT only, is_superuser always false, cross-org role injection prevented, RBAC enforced via users:write permission. https://claude.ai/code/session_01MVLBRFpkjSWd57A285vbnP
📝 WalkthroughWalkthroughThis PR implements a new org-scoped user-creation endpoint ( Changes
Sequence DiagramsequenceDiagram
participant Client
participant UserHandler
participant UserService
participant Store
participant RoleStore
participant AuditService
participant WebhookService
Client->>UserHandler: POST /api/v1/users (email, password, roles)
UserHandler->>UserHandler: Extract actorID, orgID from context
UserHandler->>UserHandler: Parse & validate request body
UserHandler->>UserHandler: Convert role_ids to UUIDs
UserHandler->>UserService: Create(ctx, orgID, actorID, input, ...)
UserService->>UserService: Validate email & password
UserService->>UserService: Hash password with bcrypt
UserService->>Store: CreateUser(orgID, email, hashedPwd, fullName)
Store-->>UserService: user (newly created)
UserService->>RoleStore: GetRoleByID(orgID, roleID)
RoleStore-->>UserService: role
UserService->>Store: AssignRoleToUser(user.ID, role.ID)
Store-->>UserService: ✓
UserService->>AuditService: LogEvent(AuditActionUserCreated, ...)
AuditService-->>UserService: ✓
UserService->>WebhookService: Dispatch(user_id, email)
WebhookService-->>UserService: ✓
UserService-->>UserHandler: *domain.User
UserHandler-->>Client: 201 Created + user JSON
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
internal/service/user_test.go (1)
302-303: Consider using a more deterministic approach for async audit log verification.The
time.Sleep(500 * time.Millisecond)introduces potential flakiness in CI environments under load. However, this is a common pragmatic pattern for testing async behavior when the audit service doesn't expose a synchronization mechanism.If flakiness becomes an issue, consider polling with a short interval and timeout:
♻️ Alternative polling approach
// Poll for audit log with timeout instead of fixed sleep deadline := time.Now().Add(2 * time.Second) var found bool for time.Now().Before(deadline) { logs, err := store.ListAuditLogs(ctx, org.ID, domain.AuditFilter{ Action: &action, Limit: 10, }) if err == nil { for _, log := range logs { if log.ResourceID != nil && *log.ResourceID == user.ID.String() { found = true break } } } if found { break } time.Sleep(50 * time.Millisecond) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/service/user_test.go` around lines 302 - 303, Replace the fixed time.Sleep(500 * time.Millisecond) in the test with a deterministic polling approach: repeatedly call store.ListAuditLogs(ctx, org.ID, domain.AuditFilter{Action: &action, Limit: 10}) on a short interval (e.g., 50ms) until you find an audit entry whose ResourceID matches user.ID.String() or a deadline (e.g., 2s) elapses; fail the test if the deadline is reached without finding the expected log. This change removes the flaky fixed sleep and makes verification robust for the async audit goroutine.internal/service/user.go (1)
43-59: Standardize error wrapping with service/function context.On Lines 43-59, some returns are unwrapped (
return nil, err) or use non-standard prefixes ("hash password: %w","create user: %w"). This weakens traceability and makes error handling less uniform across services.💡 Suggested wrapping pattern
if err := ValidatePassword(in.Password); err != nil { - return nil, err + return nil, fmt.Errorf("service.UserService.Create: %w", err) } ... hashed, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) if err != nil { - return nil, fmt.Errorf("hash password: %w", err) + return nil, fmt.Errorf("service.UserService.Create: hash password: %w", err) } ... - return nil, fmt.Errorf("create user: %w", err) + return nil, fmt.Errorf("service.UserService.Create: create user: %w", err) }As per coding guidelines "Always use sentinel errors from
internal/domain/errors.go. Wrap errors with context usingfmt.Errorf()in the format:fmt.Errorf(\"<package>.<function>: %w\", <error>)."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/service/user.go` around lines 43 - 59, Wrap all errors returned from this block with the standardized package.function sentinel pattern instead of returning raw errs or ad-hoc prefixes; e.g., replace bare returns like `return nil, err` and messages like `fmt.Errorf("hash password: %w", err)` / `fmt.Errorf("create user: %w", err)` with `fmt.Errorf("service.CreateUser: %w", err)` (or use the exact service method name if different) and when mapping domain unique constraint use `fmt.Errorf("service.CreateUser: %w", domain.ErrAlreadyExists)` combined with the pg error check; update the ValidatePassword, bcrypt.GenerateFromPassword, and s.store.CreateUser error returns to follow this `fmt.Errorf("<package>.<function>: %w", err)` convention referencing ValidatePassword, bcrypt.GenerateFromPassword, and s.store.CreateUser locations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@internal/service/user.go`:
- Around line 52-67: Currently CreateUser is called before role
validation/assignment which can leave a user persisted when role checks or
AssignRoleToUser fail; change the flow to validate all role IDs first by calling
GetRoleByID for each role in in.RoleIDs, then create the user with
s.store.CreateUser, and finally assign roles with s.store.AssignRoleToUser.
Prefer doing the create+assign sequence inside a single transactional operation
(use the store's transaction API or a single transactional method) so that
failures during AssignRoleToUser roll back the created user; update error
messages to still wrap errors (e.g., "create user" / "assign role %s") as
before.
---
Nitpick comments:
In `@internal/service/user_test.go`:
- Around line 302-303: Replace the fixed time.Sleep(500 * time.Millisecond) in
the test with a deterministic polling approach: repeatedly call
store.ListAuditLogs(ctx, org.ID, domain.AuditFilter{Action: &action, Limit: 10})
on a short interval (e.g., 50ms) until you find an audit entry whose ResourceID
matches user.ID.String() or a deadline (e.g., 2s) elapses; fail the test if the
deadline is reached without finding the expected log. This change removes the
flaky fixed sleep and makes verification robust for the async audit goroutine.
In `@internal/service/user.go`:
- Around line 43-59: Wrap all errors returned from this block with the
standardized package.function sentinel pattern instead of returning raw errs or
ad-hoc prefixes; e.g., replace bare returns like `return nil, err` and messages
like `fmt.Errorf("hash password: %w", err)` / `fmt.Errorf("create user: %w",
err)` with `fmt.Errorf("service.CreateUser: %w", err)` (or use the exact service
method name if different) and when mapping domain unique constraint use
`fmt.Errorf("service.CreateUser: %w", domain.ErrAlreadyExists)` combined with
the pg error check; update the ValidatePassword, bcrypt.GenerateFromPassword,
and s.store.CreateUser error returns to follow this
`fmt.Errorf("<package>.<function>: %w", err)` convention referencing
ValidatePassword, bcrypt.GenerateFromPassword, and s.store.CreateUser locations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f30bbdab-180b-4d11-8a4b-c225effb0977
📒 Files selected for processing (9)
internal/api/handlers/swagger_types.gointernal/api/handlers/users.gointernal/api/router.gointernal/api/router_authorization_test.gointernal/domain/audit.gointernal/service/auth.gointernal/service/auth_test.gointernal/service/user.gointernal/service/user_test.go
| user, err := s.store.CreateUser(ctx, orgID, in.Email, string(hashed), in.FullName, false) | ||
| if err != nil { | ||
| var pgErr *pgconn.PgError | ||
| if errors.As(err, &pgErr) && pgErr.Code == "23505" { | ||
| return nil, fmt.Errorf("%w: email already exists in this organization", domain.ErrAlreadyExists) | ||
| } | ||
| return nil, fmt.Errorf("create user: %w", err) | ||
| } | ||
|
|
||
| for _, roleID := range in.RoleIDs { | ||
| if _, err := s.store.GetRoleByID(ctx, orgID, roleID); err != nil { | ||
| return nil, fmt.Errorf("%w: role %s not found in organization", domain.ErrInvalidInput, roleID) | ||
| } | ||
| if err := s.store.AssignRoleToUser(ctx, orgID, user.ID, roleID); err != nil { | ||
| return nil, fmt.Errorf("assign role %s: %w", roleID, err) | ||
| } |
There was a problem hiding this comment.
Create can persist a user even when role validation fails.
On Line 52, the user is inserted before role validation/assignment. If any role is invalid (Line 62) or assignment fails (Line 65), the method returns an error but leaves the user created, which is a partial-success bug.
💡 Suggested fix (validate roles first, then create/assign)
+ // Validate all requested roles before creating the user to avoid partial success.
+ for _, roleID := range in.RoleIDs {
+ if _, err := s.store.GetRoleByID(ctx, orgID, roleID); err != nil {
+ if errors.Is(err, domain.ErrNotFound) {
+ return nil, fmt.Errorf("%w: role %s not found in organization", domain.ErrInvalidInput, roleID)
+ }
+ return nil, fmt.Errorf("service.UserService.Create: get role %s: %w", roleID, err)
+ }
+ }
+
user, err := s.store.CreateUser(ctx, orgID, in.Email, string(hashed), in.FullName, false)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, fmt.Errorf("%w: email already exists in this organization", domain.ErrAlreadyExists)
}
return nil, fmt.Errorf("create user: %w", err)
}
for _, roleID := range in.RoleIDs {
- if _, err := s.store.GetRoleByID(ctx, orgID, roleID); err != nil {
- return nil, fmt.Errorf("%w: role %s not found in organization", domain.ErrInvalidInput, roleID)
- }
if err := s.store.AssignRoleToUser(ctx, orgID, user.ID, roleID); err != nil {
- return nil, fmt.Errorf("assign role %s: %w", roleID, err)
+ return nil, fmt.Errorf("service.UserService.Create: assign role %s: %w", roleID, err)
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/service/user.go` around lines 52 - 67, Currently CreateUser is
called before role validation/assignment which can leave a user persisted when
role checks or AssignRoleToUser fail; change the flow to validate all role IDs
first by calling GetRoleByID for each role in in.RoleIDs, then create the user
with s.store.CreateUser, and finally assign roles with s.store.AssignRoleToUser.
Prefer doing the create+assign sequence inside a single transactional operation
(use the store's transaction API or a single transactional method) so that
failures during AssignRoleToUser roll back the created user; update error
messages to still wrap errors (e.g., "create user" / "assign role %s") as
before.
Implements Phase 1 of #15 — allows authenticated org admins to create
users inside their own organization without going through registration.
Changes:
optional role assignment, audit logging, and webhook dispatch
duplicate emails, cross-org isolation, and role validation
Security: org_id from JWT only, is_superuser always false, cross-org
role injection prevented, RBAC enforced via users:write permission.
https://claude.ai/code/session_01MVLBRFpkjSWd57A285vbnP
Summary by CodeRabbit
New Features
Tests